import 'dart:math';

import 'package:analyzer/dart/element/type.dart';
import 'package:analyzer/dart/element/type_provider.dart';
import 'package:analyzer/dart/element/type_system.dart';

import '../../results/results.dart';
import '../shared/dart_types.dart';
import '../dart/helper.dart';

typedef _ErrorReporter = void Function(String);

class MatchExistingTypeForQuery {
  final KnownDriftTypes knownTypes;
  final void Function(String) _reportError;
  final TypeSystem typeSystem;
  final TypeProvider typeProvider;

  MatchExistingTypeForQuery(this.knownTypes, this._reportError)
      : typeSystem = knownTypes.helperLibrary.typeSystem,
        typeProvider = knownTypes.helperLibrary.typeProvider;

  InferredResultSet applyTo(
      InferredResultSet resultSet, RequestedQueryResultType desiredType) {
    final type = _findRowType(resultSet, desiredType, _reportError);

    if (type != null) {
      return InferredResultSet(
        null,
        resultSet.columns,
        existingRowType: type,
      );
    } else {
      return resultSet;
    }
  }

  QueryRowType? _findRowType(
    InferredResultSet resultSet,
    dynamic /*DartType|RequestedQueryResultType*/ requestedType,
    _ErrorReporter reportError,
  ) {
    DartType desiredType;
    String? constructorName;
    if (requestedType is DartType) {
      desiredType = requestedType;
    } else if (requestedType is RequestedQueryResultType) {
      desiredType = requestedType.type;
      constructorName = requestedType.constructorName;
    } else {
      throw ArgumentError.value(requestedType, 'requestedType',
          'Must be a DartType of a RequestedQueryResultType');
    }

    final positionalColumns = <ArgumentForQueryRowType>[];
    final namedColumns = <String, ArgumentForQueryRowType>{};

    final unmatchedColumnsByName = {
      for (final column in resultSet.columns)
        resultSet.dartNameFor(column): column
    };

    var annotatedTypeCode = AnnotatedDartCode.type(desiredType);

    if (desiredType.isDartCoreRecord) {
      // When the general `Record` type is used, drift will generate a suitable
      // record type based on the result set.
      return _defaultRecord(resultSet);
    } else if (desiredType is RecordType) {
      final amountOfPositionalFields = desiredType.positionalFields.length;
      final amountOfColumns = resultSet.columns.length;

      // The actual record type does not always match the record type as written
      // by the user. For instance, in the query `SELECT 1, LIST(SELECT * FROM tbl)`,
      // one might use `(int, List<TblData>)` as a row type. However, the `TblData`
      // type is generated by drift and will therefore look like `dynamic` while
      // drift is running. So, when we match `dynamic` to an table class, we
      // rewrite the type to get the correct type in the end.
      final transformedTypeBuilder = AnnotatedDartCodeBuilder()..addText('(');
      var needsCommaInTransformedType = false;

      void addEntry(String? name, void Function() writeType) {
        if (needsCommaInTransformedType) transformedTypeBuilder.addText(', ');

        if (name != null) {
          transformedTypeBuilder.addText('$name ');
        }

        writeType();
        needsCommaInTransformedType = true;
      }

      void addOriginalType(DartType type, {String? name}) {
        addEntry(name, () => transformedTypeBuilder.addDartType(type));
      }

      void addCheckedType(ArgumentForQueryRowType type, DartType originalType,
          {String? name}) {
        if (type is QueryRowType) {
          addEntry(name, () => transformedTypeBuilder.addCode(type.rowType));
        } else if (type is MappedNestedListQuery) {
          addEntry(name, () {
            transformedTypeBuilder
              ..addSymbol('List', AnnotatedDartCode.dartCore)
              ..addText('<')
              ..addCode(type.nestedType.rowType)
              ..addText('>');
          });
        } else {
          addOriginalType(originalType, name: name);
        }
      }

      if (amountOfPositionalFields > amountOfColumns) {
        reportError('The desired record has $amountOfPositionalFields '
            'positional fields, but there are only $amountOfColumns columns.');
      }

      // First, match positional fields to the first columns
      final positionalsToCheck = min(amountOfPositionalFields, amountOfColumns);
      for (var i = 0; i < positionalsToCheck; i++) {
        final originalType = desiredType.positionalFields[i].type;
        final verified = _verifyArgument(
            resultSet.columns[i], originalType, 'Field ${i + 1}', reportError);
        if (verified == null) {
          addOriginalType(originalType);
          continue;
        }

        addCheckedType(verified, originalType);
        positionalColumns.add(verified);
      }

      // Then, match named fields as well
      if (desiredType.namedFields.isNotEmpty) {
        if (needsCommaInTransformedType) {
          transformedTypeBuilder.addText(',');
          needsCommaInTransformedType = false;
        }
        transformedTypeBuilder.addText('{');

        for (final parameter in desiredType.namedFields) {
          final column = unmatchedColumnsByName.remove(parameter.name);
          final originalType = parameter.type;

          if (column != null) {
            final verified = _verifyArgument(
                column, originalType, 'Field ${parameter.name}', reportError);
            if (verified != null) {
              namedColumns[parameter.name] = verified;
              addCheckedType(verified, originalType, name: parameter.name);
            }
          } else {
            addOriginalType(originalType, name: parameter.name);
            reportError(
                'Unexpected field ${parameter.name} has no matching column.');
          }
        }

        transformedTypeBuilder.addText('}');
      }

      transformedTypeBuilder.addText(')');
      annotatedTypeCode = transformedTypeBuilder.build();
    } else {
      if (resultSet.singleColumn) {
        // If we only have a single column and the desired result type is
        // compatible with it, just use it directly instead of generating a
        // nested structure.
        final verified = _verifyArgument(resultSet.scalarColumns.single,
            desiredType, 'Single column', (ignore) {});
        if (verified != null) {
          return QueryRowType(
            rowType: AnnotatedDartCode.type(desiredType),
            singleValue: verified,
            positionalArguments: const [],
            namedArguments: const {},
            isRecord: false,
          );
        }
      } else if (resultSet.matchingTable != null) {
        // Same for tables.
        final verified =
            _verifyMatchingDriftTable(resultSet.matchingTable!, desiredType);
        if (verified != null) {
          return QueryRowType(
            rowType: AnnotatedDartCode.build((builder) =>
                builder.addElementRowType(resultSet.matchingTable!.table)),
            singleValue: verified,
            positionalArguments: const [],
            namedArguments: const {},
            isRecord: false,
          );
        }
      }

      if (desiredType is InterfaceType) {
        // For interface types (we assume classes), see if we can fit the query
        // into the classes' default constructor.
        final element = desiredType.element;

        final constructor =
            desiredType.lookUpConstructor(constructorName, element.library);
        if (constructor == null) {
          if (constructorName == null) {
            reportError(
                'The class to use as an existing row type must have an unnamed '
                'constructor.');
          } else {
            reportError('The class to use as an existing row type must have a '
                'constructor named `$constructorName`');
          }

          return null;
        }

        // Match parameters to columns by name
        for (final parameter in constructor.formalParameters) {
          final column = unmatchedColumnsByName.remove(parameter.name);

          if (column != null) {
            final verified = _verifyArgument(column, parameter.type,
                'Parameter ${parameter.name}', reportError);
            if (verified == null) continue;

            if (parameter.isPositional) {
              positionalColumns.add(verified);
            } else {
              namedColumns[parameter.name!] = verified;
            }
          } else if (!parameter.isOptional) {
            reportError(
                'Unexpected parameter ${parameter.name} has no matching column.');
          }
        }
      }
    }

    return QueryRowType(
      rowType: annotatedTypeCode,
      constructorName: constructorName ?? '',
      isRecord: desiredType is RecordType,
      singleValue: null,
      positionalArguments: positionalColumns,
      namedArguments: namedColumns,
    );
  }

  /// Returns the default record type chosen by drift when a user declares the
  /// generic `Record` type as a desired result type.
  QueryRowType _defaultRecord(InferredResultSet resultSet) {
    // If there's only a single scalar column, or if we're mapping this result
    // set to an existing table, then there's only a single value in the end.
    // Singleton records are forbidden, so we just return the inner type
    // directly.
    if (resultSet.singleColumn) {
      return QueryRowType(
        rowType: AnnotatedDartCode.build(
            (builder) => builder.addDriftType(resultSet.scalarColumns.single)),
        singleValue: resultSet.scalarColumns.single,
        positionalArguments: const [],
        namedArguments: const {},
      );
    } else if (resultSet.matchingTable != null) {
      final table = resultSet.matchingTable!;
      return QueryRowType(
        rowType: AnnotatedDartCode.build(
            (builder) => builder.addElementRowType(table.table)),
        singleValue: table,
        positionalArguments: const [],
        namedArguments: const {},
      );
    }

    final namedArguments = <String, ArgumentForQueryRowType>{};

    final type = AnnotatedDartCode.build((builder) {
      builder.addText('({');

      for (var i = 0; i < resultSet.columns.length; i++) {
        if (i != 0) builder.addText(', ');

        final column = resultSet.columns[i];
        final fieldName = resultSet.dartNameFor(column);

        if (column is ScalarResultColumn) {
          builder.addDriftType(column);
          namedArguments[fieldName] = column;
        } else if (column is NestedResultTable) {
          final innerRecord = _defaultRecord(column.innerResultSet);
          builder.addCode(innerRecord.rowType);
          namedArguments[fieldName] =
              StructuredFromNestedColumn(column, innerRecord);
        } else if (column is NestedResultQuery) {
          final nestedResultSet = column.query.resultSet;

          final innerRecord = _defaultRecord(nestedResultSet);
          builder
            ..addSymbol('List', AnnotatedDartCode.dartCore)
            ..addText('<')
            ..addCode(innerRecord.rowType)
            ..addText('>');

          namedArguments[fieldName] =
              MappedNestedListQuery(column, innerRecord);
        }

        builder.addText(' $fieldName');
      }

      builder.addText('})');
    });

    return QueryRowType(
      rowType: type,
      singleValue: null,
      positionalArguments: const [],
      namedArguments: namedArguments,
      isRecord: true,
    );
  }

  /// Finds a way to map the [column] into the desired [existingTypeForColumn],
  /// which is represented as a [ArgumentForExistingQueryRowType].
  ///
  /// If this doesn't succeed (mainly due to incompatible types), reports a
  /// error through [reportError] and returns `null`.
  /// [name] is used in error messages to inform the user about the field name
  /// in their existing Dart class that is causing the problem.
  ArgumentForQueryRowType? _verifyArgument(
    ResultColumn column,
    DartType existingTypeForColumn,
    String name,
    _ErrorReporter reportError,
  ) {
    if (column is ScalarResultColumn) {
      final matches = checkType(
        column.sqlType,
        column.nullable,
        column.typeConverter,
        existingTypeForColumn,
        typeProvider,
        typeSystem,
        knownTypes,
        (msg) => reportError('$name: $msg'),
      );

      if (matches) return column;
    } else if (column is NestedResultTable) {
      final foundInnerType = _findRowType(
        column.innerResultSet,
        existingTypeForColumn,
        (msg) => reportError('For $name: $msg'),
      );

      if (foundInnerType != null) {
        return StructuredFromNestedColumn(column, foundInnerType);
      }
    } else if (column is NestedResultQuery) {
      // A nested query has its own type, which we can recursively try to
      // structure in the existing type.
      final asList =
          existingTypeForColumn.asInstanceOf(typeProvider.listElement);
      if (asList == null) {
        reportError('$name must be a List');
        return null;
      }

      final innerType = asList.typeArguments.first;
      final innerExistingType =
          _findRowType(column.query.resultSet, innerType, (msg) {
        reportError('For $name: $msg');
      });

      if (innerExistingType != null) {
        return MappedNestedListQuery(column, innerExistingType);
      }
    }

    return null;
  }

  /// Allows using a matching drift table from a result set as an argument if
  /// the the [existingTypeForColumn] matches the table's type (either the
  /// existing result type or `dynamic` if it's drift-generated).
  ArgumentForQueryRowType? _verifyMatchingDriftTable(
      MatchingDriftTable match, DartType existingTypeForColumn) {
    final table = match.table;
    if (table.hasExistingRowClass) {
      final existingType = table.existingRowClass!.targetType;

      if (typeSystem.isAssignableTo(existingType, existingTypeForColumn)) {
        return match;
      }
    } else if (typeSystem.isSubtypeOf(
        typeProvider.dynamicType, existingTypeForColumn)) {
      return match;
    }

    return null;
  }
}
